Skip to content

fix(loader-overlay)!: errors following a full review of the component (#DS-5482) - #1982

Draft
artembelik wants to merge 2 commits into
mainfrom
fix/loader-overlay-signals
Draft

fix(loader-overlay)!: errors following a full review of the component (#DS-5482)#1982
artembelik wants to merge 2 commits into
mainfrom
fix/loader-overlay-signals

Conversation

@artembelik

Copy link
Copy Markdown
Contributor

What

A full review of loader-overlay, in the same shape as the 20.3.0 component reviews.

text and caption lied about being required

@Input() text: string; // no initializer
@Input() caption: string;

The automated signal migration skipped both — it saw them read inside @if blocks and would not risk the narrowing. They are input<string>() now and report string | undefined, which is what they always held. Same pathology as KbqSplitButton.disabled in the first review wave: the call sites that were already wrong now fail to compile.

transparent was backwards as a valueless attribute

<kbq-loader-overlay transparent> passed the empty string, which is falsy, so the attribute rendered the filled background — the opposite of how it reads. With booleanAttribute it means true, and [transparent]="'false'" means false.

Closed internals

Everything the template uses to choose between a projected slot and an input is protected or private now: isEmpty, isExternalIndicator, isExternalText, isExternalCaption, spinnerSize, and the three content queries (which are signal queries as well). What the overlay renders is the contract, not how it decides.

The three getters became computed, so isEmpty now tracks a [text] that arrives later instead of relying on whoever happened to run change detection.

Migration

loader-overlay-signals runs from ng update @koobiq/components@20. It rewrites text and caption reads to calls — on receivers typed KbqLoaderOverlay and through template reference variables on <kbq-loader-overlay>, in external and inline templates — and reports the rest. size, transparent and card were already signals in 20.2.0 and are not touched.

Documented in docs/guides/migration.{en,ru}.md, section 18.

Testing

  • loader-overlay.component.spec.ts: 5 → 8 tests. The new ones pin the valueless transparent attribute, isEmpty reacting to a late [text], and the unbound inputs reporting undefined. The existing class snapshot is unchanged.
  • loader-overlay-signals/index.spec.ts: 13 tests — auto-fix, idempotence, receiver scoping, template refs, warnings, and the --fix=false path.
  • Full packages/components (4996 tests) and packages/schematics (447 tests) suites pass.
  • check-api is in sync.

No e2e screenshots were regenerated: every e2e case binds [transparent]="false" explicitly, so nothing it renders changed.

BREAKING CHANGE

🤖 Generated with Claude Code

`text` and `caption` were the two inputs the automated signal migration skipped —
it saw them read inside `@if` blocks and would not risk the narrowing. They are
`input()` now, and honest about being optional: both were declared `string` over
a field with no initializer, so an overlay that bound neither reported `undefined`
from a non-nullable type.

`transparent` gained `booleanAttribute`. `<kbq-loader-overlay transparent>` used
to pass the empty string, which is falsy, so the valueless attribute rendered the
filled background — the opposite of how it reads.

Everything the template uses to choose between a projected slot and an input —
`isEmpty`, `isExternalIndicator`, `isExternalText`, `isExternalCaption`,
`spinnerSize` and the three content queries — left the public surface. What the
overlay renders is the contract, not how it decides.

BREAKING CHANGE: `KbqLoaderOverlay.text` and `caption` are signal inputs reporting
`string | undefined`; `transparent` is a `booleanAttribute` input, so a valueless
attribute now means true; the template helpers and content queries are protected
or private. Reported and partly rewritten by the `loader-overlay-signals`
schematic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added bug Something isn't working breaking changes labels Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

Visit the preview URL for this PR (updated for commit 47fd675):

https://koobiq-next--prs-1982-bsqilact.web.app

(expires Sat, 05 Sep 2026 15:05:29 GMT)

🔥 via Firebase Hosting GitHub Action 🌎

Sign: c9e37e518febda70d0317d07e8ceb35ac43c534c

@lskramarov lskramarov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review of the loader-overlay signals rework and the new loader-overlay-signals schematic. 15 findings, most severe first; each is anchored inline. The component change itself is sound — almost everything below is in the migration schematic, plus one CI gate.

Two that block CI / make the migration a no-op:

  1. fix is undefined under ng update, so the schematic rewrites nothing.
  2. tools/check-public-api-any/baseline.json still records "loader-overlay": 1; the booleanAttribute transform makes it 2, and .github/workflows/api.yml runs that check on every PR.

Everything else is inline.


export default function loaderOverlaySignals(options: Schema): Rule {
return async (tree: Tree, context: SchematicContext) => {
const { project, fix } = options;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The auto-fix never runs under ng updatefix arrives undefined.

migrations.json declares no "schema" for any entry (0 occurrences in the file), and the CLI invokes each migration with no options at all — @angular/cli/src/commands/update/cli.js:329 calls this.executeSchematic(workflow, migration.collection.name, migration.name) against the signature executeSchematic(workflow, collection, schematic, options = {}). So schema.json's "fix": { "default": true } never reaches the rule.

A consumer runs ng update @koobiq/components@20, every overlay.text / overlay.caption read stays unmigrated, and the log fills with [loader-overlay-signals] would update <file> (run with --fix to apply). Meanwhile README.md:3 ("invoked automatically by ng update") and docs/guides/migration.en.md ("the text and caption reads are rewritten") promise the opposite.

12 of the 18 sibling migrations already guard this, and app-switcher-signals/index.ts:412 spells out why:

// `ng update` invokes migrations with no options at all, and migrations.json declares no schema, so the
// schema default never reaches us — applying the fix is the intended behaviour there.
const fix = options.fix ?? true;

index.spec.ts:37 is async function run(fix: boolean = true) and always passes fix explicitly, so no test covers the ng update call shape.

Suggested change
const { project, fix } = options;
const { project } = options;
const fix = options.fix ?? true;

readonly size: _angular_core.InputSignal<KbqDefaultSizes>;
protected readonly spinnerSize: _angular_core.Signal<ProgressSpinnerSize>;
readonly text: _angular_core.InputSignal<string | undefined>;
readonly transparent: _angular_core.InputSignalWithTransform<boolean, unknown>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yarn run check-public-api-any fails on this line — the baseline was not bumped.

The booleanAttribute transform turns transparent into InputSignalWithTransform<boolean, unknown>, a second unknown in the hand-written surface. tools/check-public-api-any/baseline.json still records "loader-overlay": 1.

I ran the tool's own filter (tools/check-public-api-any/index.ts:31-35\b(any|unknown)\b on non-generated, non-comment lines) over both revisions:

revision count
base 4bb1c9927 1 (card)
this branch 2 (card line 18, transparent line 32)

.github/workflows/api.yml:20 runs check-public-api-any on every pull_request, so the API job exits 1 with The published type surface gained \any` / `unknown`: loader-overlay: 1 → 2`.

yarn run approve-public-api-any


for (const ref of refs) {
// `\bref\.(member)\b(?!\s*\()` — skip anything already invoked, so the rewrite is idempotent.
const pattern = new RegExp(`\\b(${escapeRegExp(ref)})\\.(${members})\\b(?!\\s*\\()`, 'g');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The template rewrite is a raw String.replace over the whole file, so it corrupts unrelated .text / .caption reads.

The parse is used only to discover ref names; the replacement then ignores the AST entirely. And \b matches after a ., so the comment above ("scoped to those exact identifiers") is not true — any path whose last segment equals the ref name matches.

I ran this exact regex in node against a template declaring <kbq-loader-overlay #overlay />. Every one of these was mutated and written to disk under --fix:

input output
@for (overlay of overlays; track overlay.id) { {{ overlay.text }} } {{ overlay.text() }}TypeError: overlay.text is not a function
{{ vm.overlay.text }} {{ vm.overlay.text() }} — unrelated view-model
<p>Set overlay.text to change the label.</p> overlay.text() in the rendered copy
<!-- overlay.text is the input --> <!-- overlay.text() is the input -->
<a href="/api#overlay.text"> broken link
<span data-doc="overlay.text"> corrupted static attribute

SIGNAL_MEMBERS here is ['text', 'caption'] — two of the most common property names in any codebase — which makes this far likelier than for alert-signals' compact / alertStyle. Anchoring with (?<![.\w$]) before the ref would kill the vm.overlay.text case; the loop-variable and text-node cases need the rewrite to walk binding expressions rather than the raw string.

if (
ts.isBinaryExpression(parent) &&
parent.left === node &&
parent.operatorToken.kind === ts.SyntaxKind.EqualsToken

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compound assignments are rewritten into code that does not parse.

Only EqualsToken is treated as a write; every other assignment form falls through to the read branch on line 168 and gets () appended to the left-hand side:

source after migration
overlay.text += ' (retrying)' overlay.text() += ' (retrying)'
overlay.caption ??= 'Loading' overlay.caption() ??= 'Loading'
overlay.text++ (PostfixUnaryExpression parent — not checked at all) overlay.text()++

All three are TS2364 / Invalid left-hand side in assignment. The plain = case is deliberately left alone so the consumer gets a clean read-only error; the compound case turns a type error into a file the compiler cannot parse, at a line the schematic itself wrote. index.spec.ts:124 covers only the plain = form.

Widening the guard to ts.isAssignmentExpression(parent) (or just parent.left === node for any binary assignment operator) plus a PrefixUnaryExpression/PostfixUnaryExpression check would return them all to "leave alone".

Separately, the comment three lines down still reads // Read (incl. optional chain `x?.compact`): append `()`.compact is a KbqAlert member carried over from alert-signals; every other sibling adapted the example to its own member.

export const warnPatterns: WarnPattern[] = [
{
anchor: '\\bKbqLoaderOverlay\\b',
pattern: '(?:viewChild|ViewChild|contentChild|ContentChild)[^\\n;]*\\bKbqLoaderOverlay\\b',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This warning fires on the form the migration already fixed, and misses the form it is actually about.

I ran the pattern in node:

input matches
@ViewChild(KbqLoaderOverlay) loaderOverlay: KbqLoaderOverlay; yes
readonly overlay = viewChild(KbqLoaderOverlay); yes
readonly overlay = viewChild(\n KbqLoaderOverlay\n); no
@ViewChild(\n KbqLoaderOverlay\n)\nreadonly overlay!: KbqLoaderOverlay; no

The decorator form returns the instance, so the correct read is this.overlay.text() — which is exactly what collectReceivers + classifyAccess already produced (the declaration carries an explicit : KbqLoaderOverlay annotation, so it is a recognised receiver). The user reads "reading one is a double call, e.g. this.overlay().text()", edits the auto-fixed line, and gets TS2349: This expression is not callable. index.spec.ts:73-89 feeds precisely that source and asserts only the rewrite, never that the warning stays silent — so the false warning ships untested.

Meanwhile [^\n;]* cannot cross a newline, so with printWidth: 120 a real viewChild(KbqLoaderOverlay, { read: ElementRef, descendants: true }) wraps and goes unwarned — the one case the message is right about.

Restricting to the lowercase signal-query forms and allowing newlines fixes both: '(?:viewChild|contentChild)\\s*(?:\\.required)?\\s*\\([\\s\\S]{0,200}?\\bKbqLoaderOverlay\\b'.

commit(filePath, original, content);
}

for (const filePath of htmlPaths) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTML files get no warning pass at all.

logWarnings and warnReceiverMembers are called only inside the tsPaths loop (lines 431-432) and both operate on the TypeScript AST, so they can never see a template read. This loop calls only migrateTemplate, which rewrites SIGNAL_MEMBERS (text, caption) and nothing else.

So a template with <kbq-loader-overlay #o /> and {{ o.isEmpty }} or [size]="o.spinnerSize" is neither rewritten nor reported:

  • with strictTemplates, the consumer's build fails with TS2445 on a member the migration never mentioned;
  • without it, {{ o.isEmpty }} now renders the computed() function's source text instead of true/false, because the getter became a signal.

The ref collector already resolves which refs point at an overlay — the same list could drive a PROTECTED_MEMBERS scan over the template text and emit the same per-file warning the .ts path gets. The same gap applies to inline templates.


/** Whether a property access on a receiver is within one of the receiver's scopes. */
function inReceiverScope(node: ts.PropertyAccessExpression, sourceFile: ts.SourceFile, receivers: Receiver[]): boolean {
const receiverText = node.expression.getText(sourceFile);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Receiver scope is text equality over the whole enclosing function, so a shadowing binding is rewritten.

collectReceivers registers a parameter's scope as the entire enclosing function (findAncestor(node, isFunctionLike)), and this compares node.expression.getText(sourceFile) against the receiver text within that span — there is no binding resolution.

render(overlay: KbqLoaderOverlay) {
    this.rows.forEach((overlay) => console.log(overlay.text));
}

The arrow parameter shadows the typed one, but its read sits inside the outer method's span and matches by text → rewritten to overlay.text()TypeError on a plain row object. Same for a nested function or const of the same name.

Narrower than the template case (#3) since it needs a real name collision, but text / caption on a variable named after the component is a plausible pairing. Trimming the receiver scope when a nested declaration re-binds the same identifier would close it.

this.parent = this.elementRef.nativeElement.parentElement;
this.parent = this.nativeElement.parentElement;

this.renderer.addClass(this.parent, kbqLoaderOverlayParent);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parent is typed HTMLElement | null and neither hook guards it.

DefaultDomRenderer2.addClass is el.classList.add(name) (@angular/platform-browser dom_renderer.mjs:617) — it throws on null, it is not a no-op.

  • const ref = createComponent(KbqLoaderOverlay, { environmentInjector }); ref.changeDetectorRef.detectChanges(); before the host node is appended — parentElement is null, ngOnInit throws TypeError: Cannot read properties of null (reading 'classList').
  • const ref = vcr.createComponent(KbqLoaderOverlay); ref.destroy(); with no CD in between — ngOnInit never ran, parent is still null, and ngOnDestroy throws in removeClass.

Pre-existing, but this hook is rewritten in the diff (elementRef.nativeElementkbqInjectNativeElement()) and the PR is billed as a full review, so it is signed off now. An early if (!this.parent) return; in both hooks covers it.

}

/** Interior `[start, end]` ranges of inline `@Component({ template: '…' })` string literals. */
function collectInlineTemplateRanges(sourceFile: ts.SourceFile): Array<{ start: number; end: number }> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a byte-identical copy of an export the file already imports from.

packages/schematics/src/utils/typescript.ts:74 exports collectInlineTemplateRanges; diff between the two reports exactly one differing line — the export keyword. And line 7 already reads import { forEachClass, parseTemplate } from '../../utils/typescript';, where forEachClass is imported only to feed this copy (its sole use is line 322).

Seven migrations already import the shared one (app-switcher-signals, button-state-and-styles, button-supported-colors, button-toggle-signals-and-aria, dropdown-demote-overlay, list-tree-multiple-input, navbar-signals-and-aria), as does utils/icon-migration.ts:603.

Fix: change line 7 to import { collectInlineTemplateRanges, parseTemplate } from '../../utils/typescript'; and delete lines 318-353. −36 lines, zero behavior change — and the shared version documents a guarantee this copy silently won't inherit ("A template literal with a ${…} substitution is skipped").

### 18. Component review (20.3.0)

Ten components went through a full review in 20.3.0: notification-center, popover, search-expandable, select, split-button, title, toast, tooltip, tree and tree-select. Each review closed the members that were never part of the component's contract, moved inputs to signals where that was the point of it, and fixed the behavior it uncovered along the way. Only the changes that reach a consumer are listed here.
Components went through a full review in 20.3.0, in two waves. The first covered notification-center, popover, search-expandable, select, split-button, title, toast, tooltip, tree and tree-select; the second is the one each subsection below belongs to. Each review closed the members that were never part of the component's contract, moved inputs to signals where that was the point of it, and fixed the behavior it uncovered along the way. Only the changes that reach a consumer are listed here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sentence contradicts three of the four subsections it introduces.

Section 18's subsections are #### Loader overlay (1052), #### Search expandable (1069), #### Split button (1091) and #### Title (1113). Search-expandable, split-button and title are all named in the first-wave list in this same sentence, yet "the second is the one each subsection below belongs to" assigns them to wave two. Loader overlay is named in neither list.

A reader migrating KbqSplitButton is told split-button was reviewed in wave one and, one clause later, that the #### Split button subsection belongs to wave two. And section 18 keeps growing — it went from 3 to 4 subsections in this PR — so every future addition either re-breaks the sentence or forces another rewrite of it. Wave membership is per-subsection data being encoded in a shared intro.

The original one-liner with loader-overlay added is self-consistent and has nothing to go stale:

Suggested change
Components went through a full review in 20.3.0, in two waves. The first covered notification-center, popover, search-expandable, select, split-button, title, toast, tooltip, tree and tree-select; the second is the one each subsection below belongs to. Each review closed the members that were never part of the component's contract, moved inputs to signals where that was the point of it, and fixed the behavior it uncovered along the way. Only the changes that reach a consumer are listed here.
Eleven components went through a full review in 20.3.0: loader-overlay, notification-center, popover, search-expandable, select, split-button, title, toast, tooltip, tree and tree-select. Each review closed the members that were never part of the component's contract, moved inputs to signals where that was the point of it, and fixed the behavior it uncovered along the way. Only the changes that reach a consumer are listed here.

docs/guides/migration.ru.md:1042 carries the identical defect ("ко второй относится каждый из подразделов ниже") and needs the mirrored change.

`parent` is typed `HTMLElement | null` and went straight into `Renderer2`, which
dereferences `el.classList` — a TypeError for a host with no parent element, and
one the compiler cannot catch because Renderer2 accepts `any`. Both call sites
are guarded now.

The `@if (…; as text)` alias shadowed the input it reads from, so `text` meant
two different things either side of the block boundary.

The schematic never counted a template-only consumer, so the `transparent` note —
the one change that actually alters what such a template renders — went
unprinted. Its warning also said the content queries are `protected` when they
are `private`.

Tests: `size` was only ever exercised at its default, leaving the size class, the
`normal` → `big` spinner mapping and the whole `card` input uncovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking changes bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants